feat: controller lease acquisition metrics (JEP-0013 Phase 2) - #932
feat: controller lease acquisition metrics (JEP-0013 Phase 2)#932RoddieKieley wants to merge 3 commits into
Conversation
- Add `jumpstarter_lease_acquisitions_total{result}` on the Jumpstarter controller `/metrics` endpoint with Prometheus exemplars (default keys `client`, `lease_id`).
- Keep operator-managed Controller `-metrics-bind-address=:8080`; no Router metrics flags in this PR.
- Tests cover series registration, acquire success/failure increments, exemplars, and Controller metrics bind assertions (JEP-0013 Phase 2, controller slice).
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe controller adds Prometheus lease-acquisition metrics, records success and failure transitions during reconciliation, registers default metrics at startup, and tests metric behavior and deployment metrics binding. ChangesLease acquisition metrics
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant LeaseReconciler
participant LeaseMetrics
participant PrometheusRegistry
LeaseReconciler->>LeaseMetrics: RecordAcquisition(result, lease and client exemplars)
LeaseMetrics->>PrometheusRegistry: Increment labeled lease counter
LeaseMetrics->>PrometheusRegistry: Attach constrained exemplar labels
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@controller/deploy/operator/internal/controller/jumpstarter/controller_metrics_bind_test.go`:
- Around line 27-77: The stdlib-only TestControllerDeploymentMetricsBind
violates the operator test harness requirement. Move or adapt this assertion
into the existing envtest-based test setup for the Jumpstarter controller,
preserving validation of the metrics argument and port, then verify with the
requested controller tests, type checks, lint, and full test commands.
In `@controller/internal/metrics/lease_test.go`:
- Around line 136-151: The TestOpenMetricsTextContainsSeries serialization flow
currently uses expfmt.MetricFamilyToText, which produces Prometheus text.
Replace it with the OpenMetrics formatter or encoder, then assert
OpenMetrics-specific output including exemplar labels and the required
OpenMetrics end-of-stream terminator while preserving the existing metric and
result-label assertions.
In `@controller/internal/metrics/lease.go`:
- Around line 81-83: Update the exemplar path in the metric handling code around
AddWithExemplar to validate or constrain the client and lease_id labels before
calling it, dropping the exemplar when either value is invalid rather than
allowing a panic. Preserve the existing metric increment behavior and only omit
the exemplar for invalid resource-derived values.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7e89286d-35c8-4332-b83e-c0e79c97a350
📒 Files selected for processing (5)
controller/cmd/main.gocontroller/deploy/operator/internal/controller/jumpstarter/controller_metrics_bind_test.gocontroller/internal/controller/lease_controller.gocontroller/internal/metrics/lease.gocontroller/internal/metrics/lease_test.go
| var b strings.Builder | ||
| families, err := reg.Gather() | ||
| if err != nil { | ||
| t.Fatalf("gather: %v", err) | ||
| } | ||
| for _, f := range families { | ||
| if _, err := expfmt.MetricFamilyToText(&b, f); err != nil { | ||
| t.Fatalf("encode: %v", err) | ||
| } | ||
| } | ||
| out := b.String() | ||
| if !strings.Contains(out, LeaseAcquisitionsTotal) { | ||
| t.Fatalf("OpenMetrics text missing %s:\n%s", LeaseAcquisitionsTotal, out) | ||
| } | ||
| if !strings.Contains(out, `result="success"`) { | ||
| t.Fatalf("OpenMetrics text missing result label:\n%s", out) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate files =="
fd -a 'lease_test\.go|metrics.*go|prometheus|go.mod' . | sed 's#^\./##' | head -80
echo "== target file excerpt =="
sed -n '1,220p' controller/internal/metrics/lease_test.go
echo "== module prometheus deps =="
if [ -f go.mod ]; then cat go.mod; fi
echo "== imports/usages for expfmt/OpenMetrics in metrics =="
rg -n "MetricFamilyToText|MetricFamilyToTextToGo|MetricFamilyToTextToOpenMetrics|OpenMetrics|model\.Exemplar|exemplar|prometheus_client|client_golang" -S .Repository: jumpstarter-dev/jumpstarter
Length of output: 21512
🌐 Web query:
prometheus/client_golang v1.22.0 MetricFamilyToText OpenMetrics
💡 Result:
In prometheus/client_golang v1.22.0, the function MetricFamilyToText is located in the prometheus/common/expfmt package [1][2]. It is specifically designed for the Prometheus text format and does not generate OpenMetrics output [3][4]. To handle OpenMetrics encoding, the library uses the expfmt.NewEncoder interface [4]. When using expfmt.NewEncoder with the TypeOpenMetrics format, the library calls MetricFamilyToOpenMetrics instead of MetricFamilyToText [4]. Additionally, while v1.22.0 supports a subset of OpenMetrics (such as exemplars) via opt-in configurations in promhttp.HandlerOpts, these features are considered experimental [5][6]. There is also a specific option, promhttp.HandlerOpts.EnableOpenMetricsTextCreatedSamples, which allows the creation of OpenMetrics _created lines, though this is not recommended due to significant cardinality increases [5][7].
Citations:
- 1: https://pkg.go.dev/github.com/prometheus/client_golang@v1.22.0
- 2: https://github.com/prometheus/client_golang/tree/v1.22.0
- 3: https://github.com/prometheus/common/blob/master/expfmt/text_create.go
- 4: https://github.com/prometheus/common/blob/main/expfmt/encode.go
- 5: https://github.com/prometheus/client_golang/blob/d72eeeffefb864253bb748447701a3b381f4080f/CHANGELOG.md
- 6: https://github.com/prometheus/client_golang/blob/886616e561a35e73eb39eb7241ca34ebe4ec956c/CHANGELOG.md
- 7: https://github.com/prometheus/client_golang/blob/9351edd797213e38dacc6a1dbd4514dab7abaafc/CHANGELOG.md
🌐 Web query:
prometheus/client_golang MetricFamilyToTextToOpenMetrics v1.22.0
💡 Result:
In the prometheus/client_golang library, functions related to metric parsing and encoding, such as MetricFamilyToText, are not located within the client_golang module itself [1][2]. Instead, these utilities are contained within the prometheus/common/expfmt package [1][3]. While prometheus/client_golang version 1.22.0 was released on April 7, 2025 [4][5], it does not introduce a specific function named MetricFamilyToTextToOpenMetrics. The primary functions for handling text-based exchange formats in the Prometheus ecosystem are typically found in the expfmt package, which includes: 1. MetricFamilyToText: Converts a MetricFamily protobuf message into the standard Prometheus text format [3]. 2. TextToMetricFamilies: Parses the Prometheus text format into MetricFamily protobuf messages [3][6]. Users seeking to interact with the OpenMetrics format or manipulate metric families generally utilize the tools provided by prometheus/common/expfmt [1][3]. Note that the client_golang library itself focuses on instrumentation and API client functionality, while common serialization and formatting logic is abstracted into the prometheus/common module [1][2]. If you are encountering a reference to a function named MetricFamilyToTextToOpenMetrics, it is likely a custom implementation or a misidentification of existing encoder/decoder functions within the expfmt package.
Citations:
- 1: https://pkg.go.dev/github.com/prometheus/client_golang@v1.22.0
- 2: https://github.com/prometheus/client_golang/tree/v1.22.0
- 3: https://pkg.go.dev/github.com/lidezhu/tidb/_vendor/src/github.com/prometheus/common/expfmt
- 4: https://github.com/prometheus/client_golang/releases/tag/v1.22.0
- 5: https://groups.google.com/g/prometheus-announce/c/ZJ1UWy4HE5M
- 6: https://qiita.com/fits/items/6e2dd785c7495cb37022
Use an actual OpenMetrics encoder.
MetricFamilyToText emits the Prometheus text format, not OpenMetrics, so TestOpenMetricsTextContainsSeries cannot catch OpenMetrics serialization bugs. Encode the gathered families with the OpenMetrics formatter/encoder and assert the OpenMetrics-specific parts, including exemplar labels and the OpenMetrics terminator.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@controller/internal/metrics/lease_test.go` around lines 136 - 151, The
TestOpenMetricsTextContainsSeries serialization flow currently uses
expfmt.MetricFamilyToText, which produces Prometheus text. Replace it with the
OpenMetrics formatter or encoder, then assert OpenMetrics-specific output
including exemplar labels and the required OpenMetrics end-of-stream terminator
while preserving the existing metric and result-label assertions.
| func NewLeaseMetrics() *LeaseMetrics { | ||
| return &LeaseMetrics{ | ||
| acquisitions: prometheus.NewCounterVec( | ||
| prometheus.CounterOpts{ | ||
| Name: LeaseAcquisitionsTotal, | ||
| Help: "Lease acquire attempts on the Jumpstarter controller.", | ||
| }, | ||
| []string{"result"}, | ||
| ), | ||
| } | ||
| } |
There was a problem hiding this comment.
Counter time series not pre-initialized with zero values. So, before any lease acquisition, scraping /metrics produces TYPE/HELP metadata but no data lines.
There was a problem hiding this comment.
Do you need to pre-initialize to 0 ? I assumed that was the default in go.
There was a problem hiding this comment.
Zero values in go - " Variables declared without an explicit initial value are given their zero value. "
There was a problem hiding this comment.
https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics - its lazily initialized hence if you do do not prepopulate no graph will show.
| func setUnsatisfiableAndRecord(lease *jumpstarterdevv1alpha1.Lease, reason, messageFormat string, a ...any) { | ||
| already := meta.IsStatusConditionTrue( | ||
| lease.Status.Conditions, | ||
| string(jumpstarterdevv1alpha1.LeaseConditionTypeUnsatisfiable), | ||
| ) | ||
| lease.SetStatusUnsatisfiable(reason, messageFormat, a...) | ||
| if !already { | ||
| recordLeaseAcquisition(lease, jmpmetrics.ResultFailure) | ||
| } |
There was a problem hiding this comment.
Some test for the check that prevents double-counting on re-reconciliation would be nice.
| } | ||
|
|
||
| // Default is the process-wide lease metrics instance used by the controller. | ||
| var Default = NewLeaseMetrics() |
There was a problem hiding this comment.
All controller tests share the global counter, breaking test isolation.
There was a problem hiding this comment.
Don't those tests run in serial?, is it worth complicating the code in favor of testability? could be, just sharing the question here, I am not familiar with the prometheus metrics framework to be able to figure out if it'd get too complex, or if it really won't.
There was a problem hiding this comment.
Evaluated and deferred as not required right now, although may be in the future.
H4. Global Default breaks test isolation
lease.go (var Default)
Claim: All controller tests share one process-wide counter.Verdict: Soft accept — improve if cheap; don’t over-engineer
Metrics package tests already use NewLeaseMetrics() + private registries. Pain appears only if controller tests assert Default values (or run in parallel against it).
mangelajo’s question is fair: serial Ginkgo reduces flake risk; full DI may not be worth it for Phase 2.
Recommended change: Prefer injecting *LeaseMetrics on LeaseReconciler (defaulting to Default in production). Avoid a large framework change. If injection is deferred, at least don’t assert absolute global counter values in controller tests.
| lease.Status.ExporterRef = &corev1.LocalObjectReference{ | ||
| Name: selected.Exporter.Name, | ||
| } | ||
| recordLeaseAcquisition(lease, jmpmetrics.ResultSuccess) |
There was a problem hiding this comment.
The success metric is recorded here before status is persisted to the API server.
| func recordLeaseAcquisition(lease *jumpstarterdevv1alpha1.Lease, result string) { | ||
| exemplars := map[string]string{} | ||
| if lease != nil { | ||
| exemplars["lease_id"] = lease.Name |
There was a problem hiding this comment.
from the pov of exemplars and useful data, each lease_id is different, so it would not provide much statistical value.
What do you think about recording the assigned exporter name instead?
There was a problem hiding this comment.
Having only really run this in development in my own homelab I requested an evaluation of the implications of having lease_id both with and without and additional exporter as the initial take was 'The JEP has a specific design and the lease_id should not be removed'. The results sound reasonable, but as one of the authors of the JEP-0013 you might have a better idea than me if it simply "sounds reasonable" or is actually reasonable:
H5 in detail:
lease_idvs exporter name on controller exemplarsmangelajo’s point is right for aggregation: each
lease_idis unique, so it does not help “which hardware fails often?” style rates. Exemplars are not for that. JEP-0013 puts unbounded IDs in exemplars for drill-down, and keeps bounded identity likeexporteron Prom/Loki labels where series exist for that purpose.This PR’s series is only:
jumpstarter_lease_acquisitions_total{result="success|failure"}with default exemplars
client+lease_id. There is noexporterlabel on this counter.1. With only
client+lease_id(today / JEP defaults)Operator flow when a dashboard shows a spike in failures (or a success blip):
- Grafana shows an exemplar dot on
jumpstarter_lease_acquisitions_total{result="failure"}(last-seen exemplar per series).- Click → see e.g.
{client="ci-bot", lease_id="lease-abc"}.- Use
lease_idas the join key into Loki, e.g.{component="controller"} | json | lease_id="lease-abc"
(and the same filter on exporter/cli streams once Phase 1/3 correlation fields are on the lines).- From those log lines you recover the rest: assigned exporter (if any), selector, unsatisfiable reason,
spec.context, etc.So without an exporter exemplar key,
lease_idis the primary bridge from “this metric sample” → “the one lease’s logs / CR”.clientanswers “which client,” not “which DUT.”Limits of this path:
- You need a working Loki (or API) lookup; the exemplar panel alone does not name the exporter.
- Failure acquires often have no exporter assigned (
ExporterNotFound,NoAccess, …), so an exporter key would be empty anyway.- Last-exemplar-wins: on a busy
result="failure"series you only see the most recent lease, not a histogram of exporters (that still comes from logs or from exporter-labeled metrics elsewhere).2. If
exporteris also on the exemplar (in addition tolease_id)On success (and only when
Status.ExporterRefis set), the exemplar might look like:
{client="ci-bot", lease_id="lease-abc", exporter="sidekick-t450s"}Operator flow:
- Same spike / click on the exemplar.
- Immediately see which exporter was assigned — useful for “was this the flaky board?” without opening Loki first.
- Still use
lease_idfor precise correlation: one exporter has many leases over time;lease_idpicks the exact run and joins to that lease’s log cluster /spec.context.- Optionally pivot: Loki by
exporter="sidekick-t450s"for hardware trends, or bylease_idfor one CI run.What this does not replace:
- Rates per exporter still belong on metrics that already have an
exporterlabel (e.g. plannedjumpstarter_operations_total{exporter=...}), not on unique exemplar values.- Replacing
lease_idwithexporteralone would weaken the join: many samples share one exporter name; you lose one-click identity of the lease that caused this observation.Budget note: OpenMetrics caps exemplars at 128 runes. Adding
exporterplus key name costs ~15–40+ characters and can force truncation oflease_id/client(you already log when that happens).Side-by-side
| Goal | lease_id only | lease_id + exporter |
|---|---|---|
| Join metric sample → one lease’s logs | Direct | Direct (same) |
| See DUT name in the exemplar popup | Indirect (via Loki/API) | Direct on success |
| Failure with no assignment | Fine (lease_id still set) | exporter omitted/empty |
| “Which exporter fails often?” | Wrong tool (use labeled series / LogQL) | Still wrong tool for rates; only a hint on last sample |
| Cardinality of the Prom series | Unchanged | Unchanged |
Verdict for #932
Keep JEP defaults: client + lease_id. Do not replace lease_id with exporter.
Optional later (or a short PR reply to mangelajo): on success only, also attach exporter when Status.ExporterRef is set, still under the allowlist / 128-rune budget — additive drill-down, not a substitute for lease_id.
That matches the JEP table: lease_id is exemplar-for-drill-down; exporter is primarily a bounded Prom/Loki label on exporter-scoped metrics, not the lease-acquire join key.
There was a problem hiding this comment.
@kirkbrauer @raballew As others who reviewed the original JEP-0013 PR #631 will likely be afk for a few days at least maybe you have an educated opinion to chime in with?
| func NewLeaseMetrics() *LeaseMetrics { | ||
| return &LeaseMetrics{ | ||
| acquisitions: prometheus.NewCounterVec( | ||
| prometheus.CounterOpts{ | ||
| Name: LeaseAcquisitionsTotal, | ||
| Help: "Lease acquire attempts on the Jumpstarter controller.", | ||
| }, | ||
| []string{"result"}, | ||
| ), | ||
| } | ||
| } |
There was a problem hiding this comment.
Do you need to pre-initialize to 0 ? I assumed that was the default in go.
| } | ||
|
|
||
| // Default is the process-wide lease metrics instance used by the controller. | ||
| var Default = NewLeaseMetrics() |
There was a problem hiding this comment.
Don't those tests run in serial?, is it worth complicating the code in favor of testability? could be, just sharing the question here, I am not familiar with the prometheus metrics framework to be able to figure out if it'd get too complex, or if it really won't.
jumpstarter_lease_acquisitions_total{result}on the Jumpstarter controller/metricsendpoint with Prometheus exemplars (default keysclient,lease_id).-metrics-bind-address=:8080; no Router metrics flags in this PR.